<?php

# $Id$
# Copyright 2010, Rasmus Schultz <http://mindplay.dk>

/**
 * Abstract base class for a Nested Property Set Parser.
 */
abstract class NPSParser {
  
  /**
   * Parse ASCII input
   */
  public function parse($input)
  {
    $this->start();
    $this->scan($input);
    return $this->end();
  }
  
  /**
   * Parse ASCII file
   */
  public function parseFile($path)
  {
    $this->start();
    $f = fopen($path,'r');
    while ($input = fread($f,4096))
      $this->scan($input);
    return $this->end();
  }
  
  /**
   * Input location
   */
  protected $line;
  protected $char;
  
  /**
   * Parser states
   */
  const STATE_WAIT = 0;
  const STATE_WORD = 1;
  const STATE_COMMENT = 2;
  const STATE_HAVE_WORD = 3;
  const STATE_HAVE_PROPERTY_NAME = 4;
  const STATE_GET_VALUE = 5;
  
  /**
   * Character classes
   */
  protected $word_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_.@";
  protected $whitespace = "\n\r\t ";
  protected $linefeed = "\n\r";
  
  /**
   * Parser state
   */
  protected $state;
  protected $word;
  protected $value;
  protected $last_state;
  
  /**
   * Initialize parser state
   */
  protected function start()
  {
    $this->line = 1;
    $this->char = 0;
    $this->state = self::STATE_WAIT;
    $this->last_state = null;
    $this->word = '';
    $this->value = '';
  }
  
  /**
   * Main scanner/parser loop
   */
  protected function scan($input)
  {
    foreach (str_split($input,1) as $char)
    {
      if ($char == "\n")
      {
        $this->char = 0;
        $this->line++;
      }
      else
      {
        $this->char++;
      }
      
      $is_wc = strpos($this->word_chars,$char)!==false;
      $is_lf = strpos($this->linefeed,$char)!==false;
      $is_ws = strpos($this->whitespace,$char)!==false;
      
      switch ($this->state)
      {
        case self::STATE_WAIT:
          if ($is_wc)
          {
            # beginning of word
            $this->word = $char;
            $this->state = self::STATE_WORD;
          }
          else if ($char == '#')
          {
            # beginning of comment
            $this->last_state = $this->state;
            $this->state = self::STATE_COMMENT;
          }
          else if ($char == '}')
          {
            # end of collection
            $this->endCollection();
            $this->state = self::STATE_WAIT;
          }
          else if (!$is_ws)
          {
            $this->fail('only word characters (a-z, A-Z and underscore) and whitespace is allowed here');
          }
        break;
        
        case self::STATE_WORD:
          if ($is_wc)
          {
            # continued word
            $this->word .= $char;
          }
          else if ($is_ws)
          {
            # end of word
            $this->state = self::STATE_HAVE_WORD;
          }
          else switch ($char)
          {
            case ":":
              $this->state = self::STATE_HAVE_PROPERTY_NAME;
            break;
            case "{":
              # begin collection
              $this->addCollection($this->word);
              $this->state = self::STATE_WAIT;
            break;
            default:
              $this->fail('invalid character - expected ":", "{" or whitespace');
          }
        break;
        
        case self::STATE_COMMENT:
          if ($is_lf)
          {
            # end of comment:
            $this->state = $this->last_state;
          }
        break;
        
        case self::STATE_HAVE_WORD:
          switch ($char)
          {
            case ":":
              $this->state = self::STATE_HAVE_PROPERTY_NAME;
            break;
            case "{":
              # begin collection
              $this->addCollection($this->word);
              $this->state = self::STATE_WAIT;
            break;
            default:
              if (!$is_ws)
                $this->fail('invalid character - expected ":", "{" or whitespace');
          }
        break;
        
        case self::STATE_HAVE_PROPERTY_NAME:
          if ($is_lf)
          {
            # empty property:
            $this->addProperty($this->word, '');
            $this->state = self::STATE_WAIT;
          }
          else if (!$is_ws)
          {
            # begin value:
            $this->value = $char;
            $this->state = self::STATE_GET_VALUE;
          }
        break;
        
        case self::STATE_GET_VALUE:
          if ($is_lf)
          {
            # end property value:
            $this->addProperty($this->word, $this->value);
            $this->state = self::STATE_WAIT;
          }
          else
            $this->value .= $char;
        break;
        
        default:
          $this->fail('internal error - unknown state '.$this->state);
      }
    }
  }
  
  /**
   * Called when a property is found
   */
  abstract protected function addProperty($name, $value);
  
  /**
   * Called when a collection begins
   */
  abstract protected function addCollection($name);
  
  /**
   * Called when a collection ends
   */
  abstract protected function endCollection();
  
  /** 
   * Called when the input stream ends.
   * In a concrete class, this method should return the parsed result.
   */
  protected function end()
  {
    $this->scan("\n");
    
    if ($this->state != self::STATE_WAIT)
      $this->fail('unexpected end of file');
  }
  
  /**
   * Exception generator
   */
  protected function fail($reason)
  {
    throw new Exception('parser error in line '.$this->line.', character '.$this->char.': '.$reason);
  }
  
}

/**
 * A Nested Property Set parser that returns nested arrays.
 */
class NPSArrayParser extends NPSParser
{
  /**
   * Data and collection stacks
   */
  protected $data;
  protected $stack;
  
  /**
   * Initialize parser state
   */
  protected function start()
  {
    parent::start();
    
    $this->data = array();
    $this->stack = array(&$this->data);
  }
  
  /**
   * Add an element to the array
   */
  protected function addProperty($name, $value)
  {
    $c = & $this->stack[count($this->stack)-1];
    
    if (array_key_exists($name, $c))
      $this->fail('duplicate key "'.$name.'"');
    
    $c[$name] = $value;
  }
  
  /**
   * Add a nested array
   */
  protected function addCollection($name)
  {
    $c = & $this->stack[count($this->stack)-1];
    
    if (array_key_exists($name, $c))
      $this->fail('duplicate key "'.$name.'"');
    
    $c[$name] = array();
    
    $this->stack[] = & $c[$name];
  }
  
  /**
   * Complete a nested array
   */
  protected function endCollection()
  {
    array_pop($this->stack);
    
    if (count($this->stack)==0)
      $this->fail('unexpected "}"');
  }
  
  /** 
   * Called when the input stream ends
   */
  protected function end()
  {
    parent::end();
    
    if (count($this->stack)!=1)
      $this->fail('unexpected end of file');
    
    return $this->data;
  }
}
